logo
menu

토스 이펙티브 컴포넌트 1 | 컴파운드 패턴을 활용한 Dropdown 구현하기

2023. 10. 24.

  • #리액트

기반이 되는 Dropdown 을 간단하게 구현해보자!

토스에서 제안한 토스ㅣSLASH 22 - Effective Component 을 보면 Select 를 구성하는데 Dropdown을 기반으로 설명한다. 하지만 자세한 코드가 없어 유추해서 구현해보고 그 내용을 공유하고자 한다. 해당 Dropdown 을 Compound Component 패턴 기반으로 구현했다.
 
notion image
notion image
토스 컨퍼런스 내용중에 일부로 Dropdown 들의 조합으로 Select 컴포넌트를 구현하고 FrameworkSelect 컴포넌트에서 위 UI 를 기반으로 사용한다.
위 컴포넌트를 구현하면서 아이디어를 잡기까지 약간의 시행착오가 있었지만 위 Dropdown 컴포넌트를 구현하면서 쉽게 공유할 수 있다고 생각되어 해당 내용부터 공유하고자 한다.
 

아주 간단한 Dropdown 컴포넌트 (feat. Compound 패턴)

notion image
결과 화면은 위와 같이 아주 간단한 Dropdown 이다.
 

Dropdown Context

  • compound 패턴은 하나의 컴포넌트를 이루기 위해 여러 컴포넌트들의 조합으로 구성하는 패턴으로 각 컴포넌트 당 하나의 역할과 책임을 부여할 수 있고, 필요한 요소들만 조합해서 사용할 수 있기에 변경 및 유지보수가 용이하다.
  • 이런 경우 Dropdown 에 필요한 상태들을 각 요소의 전달해야하기 때문에 Context 를 사용해서 상태를 공유한다.
  • example1/Dropdown/context.ts
import { createContext, useContext } from 'react'; interface DropdownContextValue { isOpen: boolean; select: string; onOpen: () => void; onClose: () => void; onSelect: (item: string) => void; } export const DropdownContext = createContext<DropdownContextValue | null>(null); // Provider 를 정의해도 되지만, Provider 가 Dropdown 이기 때문에 해당 내용을 Dropdown 컴포넌트에서 정의 export const useDropdownContext = () => { const context = useContext(DropdownContext); if (context === null) { throw new Error('useDropdownContext must be used within a DropdownProvider'); } return context; };

Dropdown

  • example1/Dropdown/index.tsx
  • Dropdown 에서 처리되어야 하는 기능을 (Menu 의 노출 여부를 제어하는 isOpen, 아이템 선택 기능) 담당하고 이를 Context 로 관리하여 그 하위 Trigger, Menu, Item 컴포넌트에서도 접근하여 사용하고자 한다
import { PropsWithChildren, useState } from 'react'; import { DropdownContext } from './context'; import { Item } from './Item'; import { Menu } from './Menu'; import { Trigger } from './Trigger'; interface Props { value: string; onChange: React.Dispatch<React.SetStateAction<string>>; } export function Dropdown({ value, onChange, children }: PropsWithChildren<Props>) { const [isOpen, setIsOpen] = useState(false); const [select, setSelect] = useState(value); const handleOpen = () => setIsOpen(true); const handleClose = () => setIsOpen(false); const handleSelect = (item: string) => { setSelect(item); onChange(item); handleClose(); }; return ( <DropdownContext.Provider value={{ isOpen, select, onOpen: handleOpen, onClose: handleClose, onSelect: handleSelect, }} > {children} </DropdownContext.Provider> ); } Dropdown.Trigger = Trigger; Dropdown.Menu = Menu; Dropdown.Item = Item;
  • Dropdown 의 기능 (open, close 기능, 선택한 아이템으로 변경 등) 을 정의하고 Context Provider 로 정의한다.
    • → 그럼 해당 하위 요소들에서 해당 기능들을 사용할 수 있다.
      → 우선 Menu의 노출 여부를 제어하는 내부 상태 isOpen을 분리후 이 상태를 Dropdown이라는 컴포넌트로 관리한다.
  • Compound 패턴을 이용해서 Dropdown 에서 필요한 기능들을 조합해서 사용한다.
    • Compound 패턴을 사용하면 해당 컴포넌트에서 필요한 기능만 정의해서 사용하기에 단일 책임 원칙 등 유지보수하기 좋은 구조가 된다.

Dropdown.Trigger

  • 해당 요소의 Dropdown 이 열리고 닫히는 상호작용을 담당하는 trigger 컴포넌트이다
  • example1/Dropdown/Trigger.tsx
import { ReactNode } from 'react'; import { useDropdownContext } from './context'; // export const Trigger = ({ children }: PropsWithChildren) => { export const Trigger = ({ as }: { as: ReactNode }) => { const context = useDropdownContext(); const { isOpen, onOpen, onClose } = context; // return <div onClick={!isOpen ? onOpen : onClose}>{children}</div>; return <div onClick={!isOpen ? onOpen : onClose}>{as}</div>; };
  • children 으로도 구현할 수 있지만, 예제에 맞춰서 as 로 관리하였다.

Dropdown.Menu

  • example1/Dropdown/Menu.tsx
  • 열고 닫힌 상태에 따라 노출 여부가 결정하는 메뉴 컴포넌트
import { PropsWithChildren } from 'react'; import { useDropdownContext } from './context'; export function Menu({ children }: PropsWithChildren) { const { isOpen } = useDropdownContext(); if (!isOpen) return null; return ( <div> <div>{children}</div> </div> ); }

Dropdown.Item

  • example1/Dropdown/Item.tsx
  • 각각의 메뉴를 구성하는 Item 컴포넌트를 이용하여 상호작용을 담당하도록 하였다.
import { useDropdownContext } from './context'; export function Item({ value }: { value: string }) { const { select, onSelect } = useDropdownContext(); return ( <div className={select === value ? 'active' : ''} onClick={() => onSelect(value)} > {value} </div> ); }
  • 현재 선택된 값인지 select === value 으로 판단할 수 있다.
  • 아이템을 선택하면 선택한 값을 변경해야 하기에 onSelect 를 통해 변경하였다.
 
이를 잘 조합해서 사용하면 아주 간단한 Dropdown 컴포넌트를 구현할 수 있다.
  • example1/App.tsx
import { useState } from 'react'; import { Dropdown } from '@/example1/Dropdown'; export function ExampleApp1() { const [selected, change] = useState('선택'); const options = ['Next.js', 'Remix', 'Gatsby', 'Relay']; return ( <div> <h1>ExampleApp1</h1> <Dropdown value={selected} onChange={change}> <Dropdown.Trigger as={<button>{selected}</button>} /> <Dropdown.Menu> {options.map((option, index) => ( <Dropdown.Item key={index} value={option} /> ))} </Dropdown.Menu> </Dropdown> </div> ); }
 
토스 컨퍼런스 내용을 보면 Dropdown 컴포넌트를 이용해서 Select 컴포넌트를 구현했는데 이를 구현하면 아래와 같다
// example1/Select.tsx import { ReactNode } from 'react'; import { Dropdown } from './Dropdown'; type Props = { value: string; onChange: React.Dispatch<React.SetStateAction<string>>; trigger: ReactNode; options: string[]; }; export function Select({ value, onChange, trigger, options }: Props) { return ( <Dropdown value={value} onChange={onChange}> {/* children 으로 처리된 경우 */} {/* <Dropdown.Trigger>{Trigger}</Dropdown.Trigger> */} <Dropdown.Trigger as={trigger} /> <Dropdown.Menu> {options.map((option, index) => ( <Dropdown.Item key={index} value={option} /> ))} </Dropdown.Menu> </Dropdown> ); }
 
💡
이렇게 컴파운드 패턴을 이용해서 Dropdown 을 구현했고 이를 이용해서 Select 컴포넌트를 구현했다. 다음 편에는 컨퍼런스에서 보여준 컴포넌트를 직접 구현해보자!

ref